Skip to content

feat(#298): artifact upload endpoint + pluggable storage backend - #319

Merged
bg-playground merged 5 commits into
mainfrom
copilot/add-artifacts-upload-endpoint
May 8, 2026
Merged

feat(#298): artifact upload endpoint + pluggable storage backend#319
bg-playground merged 5 commits into
mainfrom
copilot/add-artifacts-upload-endpoint

Conversation

Copilot AI commented May 8, 2026

Copy link
Copy Markdown
Contributor

Implements POST /api/v1/external-results/artifact (multipart) with a pluggable StorageBackend abstraction, post-buffer size enforcement, filename sanitization, and an audit log shape load-bearing to the smoke workflow in PR #314.

Endpoint

Multipart field names are a locked contract (reporter pinned at SHA ab5d7c1):

Field Type Notes
case_result_id string (UUID) FK → external_case_results.id
kind string screenshot | video | trace | log | other
filename string Original filename including extension
file binary Content-Type header of this part is the artifact MIME type

content_type and size_bytes are derived, not sent as separate fields.

Filename sanitization (path-traversal defense)

filename is validated with two layers of defense:

  1. API layer — filename is rejected with 422 if it differs from its own os.path.basename() (catches ../, subdir/, /etc/) or fails the allowlist regex ^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$ (blocks null bytes, backslashes, dot-only names like . and .., names exceeding 255 chars, etc.).
  2. LocalFsBackend layersave() calls .resolve() + is_relative_to() as a secondary check; raises ValueError if the resolved path escapes the artifact root.

Storage abstraction (backend/app/storage/)

  • StorageBackend ABC — save(stream, *, key, content_type) → StorageResult and url_for(key) → str
  • LocalFsBackend — writes under BGSTM_ARTIFACTS_DIR; URLs via BGSTM_ARTIFACT_URL_PREFIX
  • S3Backend — stub; raises NotImplementedError("S3 backend not yet implemented; set BGSTM_STORAGE_BACKEND=local")
  • get_storage() is a function, not a module-level singleton — tests swap settings without import-time side effects

Size enforcement

The server enforces BGSTM_ARTIFACT_MAX_BYTES (default 50 MiB) by reading the upload in 64 KiB chunks and accumulating a byte count, returning 413 and cleaning up the temp file when the limit is exceeded. Note: FastAPI/Starlette fully parses and spools the multipart body before the handler runs, so this check operates on the spooled copy rather than the live network stream. For first-line DoS protection, configure your reverse proxy (e.g. nginx client_max_body_size) to reject oversized bodies before they reach the application. True in-stream early-abort is a follow-up improvement.

Database

New external_case_artifacts table with ON DELETE CASCADE FK to external_case_results.id. Migration l1m2n3o4p5q6 follows the lesson from #317: uses postgresql.ENUM(..., create_type=False) + .create(bind, checkfirst=True) in upgrade() and .drop(bind, checkfirst=True) in downgrade(). Model-side Enum carries explicit name="artifact_kind" and create_type=False. Index name on case_result_id is explicit (idx_external_case_artifacts_case_result_id) and matches the migration to prevent alembic --autogenerate noise.

Audit log

Every successful upload emits external_results.artifact.upload with exactly these five fields (required by assert.py in PR #314):

{
  "case_result_id": "<uuid>",
  "kind": "screenshot",
  "size_bytes": 20480,
  "filename": "failure-state.png",
  "content_type": "image/png"
}

Other

  • Dev-only StaticFiles route at /artifacts mounted only when BGSTM_STORAGE_BACKEND=local
  • python-multipart==0.0.27 added to requirements.txt (patched against two prior CVEs)
  • Spec doc (external_results_v1.md) updated: §7 rewritten for the actual multipart contract and accurate size-enforcement description; new §g documents the storage abstraction and config surface
Original prompt

Goal

Implement issue #298 — artifacts upload endpoint + pluggable storage backend for the external-results API. This builds on #317 (external case results), which is now merged on main.

Reference issues:

Scope

New endpoint

  • POST /api/v1/external-results/artifact — multipart upload.
  • Multipart field names are locked and must not be renamed (the reporter at bgstm-playwright-frameworks pinned SHA ab5d7c1 already sends these):
    • case_result_id (string, UUID)
    • kind (string, one of the artifact_kind enum values — at minimum: screenshot, video, trace, log, other)
    • filename (string)
    • file (the binary file part)
  • Response returns { id, url, kind, size_bytes, content_type, filename, case_result_id }.
  • No GET endpoints in this PR. Smoke relies on the URL returned at create time + audit-log entries.

Storage abstraction

  • New StorageBackend ABC with at least: save(stream, *, key, content_type) -> StorageResult and url_for(key) -> str.
  • LocalFsBackend implementation — writes under a configured root (e.g. BGSTM_ARTIFACTS_DIR), returns a URL served by a static dev-only route.
  • S3Backend stub class that raises NotImplementedError with a clear message ("S3 backend not yet implemented; set BGSTM_STORAGE_BACKEND=local").
  • get_storage() is a function, not a module-level singleton — tests must be able to swap settings cleanly without import-time side effects.
  • Backend selection driven by settings (e.g. BGSTM_STORAGE_BACKEND=local|s3).

Streaming size + content-type enforcement

  • Enforce a max size (configurable, e.g. BGSTM_ARTIFACT_MAX_BYTES) while streaming — do not read the full body into memory.
  • On exceeding the limit, return 413 Payload Too Large and ensure the partial file is cleaned up.
  • Validate content_type against an allowlist (or a sensible default allowlist per kind).
  • Add a test that asserts only a partial chunk is written before 413 fires (catches "looks streaming, actually buffers" regressions).

Database

  • New external_case_artifacts table with FK to external_case_results.id (cascade delete).
  • Columns at minimum: id (UUID PK), case_result_id (UUID FK), kind (enum artifact_kind), filename, content_type, size_bytes, storage_key, url, created_at.
  • Add artifact_kind Postgres enum.

Alembic migration — critical (lesson from #317 round 1)

  • Use postgresql.ENUM(..., name="artifact_kind", create_type=False) and call .create(bind, checkfirst=True) manually in upgrade() from the first commit. Do not rely on SQLAlchemy auto-creating the type.
  • Mirror in downgrade() with .drop(bind, checkfirst=True) after the table drop.
  • Model-side Enum(...) MUST have explicit name="artifact_kind" and create_type=False.

Static route (dev only)

  • Mount a static route that serves files from the local artifacts dir, gated so it only mounts when BGSTM_STORAGE_BACKEND=local.

Audit log

Spec doc

  • Update docs/specs/external_results_v1.md (or the equivalent on main) to document the new endpoint, the multipart contract, the storage abstraction, the size/content-type enforcement, and the audit-log shape.

Out of scope

Acceptance criteria

  • POST /api/v1/external-results/artifact accepts multipart with field names exactly case_result_id, kind, filename, file.
  • StorageBackend ABC + LocalFsBackend impl + S3Backend stub that raises a clear NotImplementedError.
  • get_storage() is a function (no module-level singleton); tests can swap settings cleanly.
  • external_case_artifacts table with FK to external_case_results.id.
  • Alembic migration uses create_type=False + manual .create(checkfirst=True) from the first commit; same in downgrade with .drop(checkfirst=True).
  • Model-side Enum(...) has explicit name="artifact_kind" and create_type=False.
  • Streaming size enforcement: 413 on overflow, partial file cleaned up; test asserts only a partial chunk is written before 413.
  • Content-type allowlist enforced.
  • Audit-log details includes all ...

This pull request was created from Copilot chat.

Copilot AI and others added 2 commits May 8, 2026 16:40
- POST /api/v1/external-results/artifact — multipart upload endpoint
  - Fields: case_result_id, kind, filename, file (locked contract)
  - Content-type derived from file part header
  - Allowlist enforcement (bypass for kind=other)
  - Streaming size enforcement (64 KiB chunks, 413 + cleanup on overflow)
  - Audit log with all 5 required fields
- StorageBackend ABC + LocalFsBackend + S3Backend stub (raises clearly)
- get_storage() function (no module-level singleton)
- external_case_artifacts model with artifact_kind enum (create_type=False)
- Alembic migration l1m2n3o4p5q6 with PG enum create/drop (checkfirst=True)
- Dev-only static route for local artifacts (gated on BGSTM_STORAGE_BACKEND=local)
- python-multipart==0.0.27 added to requirements.txt
- 10 new tests covering happy path, 413+cleanup+partial-write, 415, 422, 404, 401, S3 stub
- Spec doc updated with multipart contract, storage abstraction, and audit-log shape

Agent-Logs-Url: https://github.com/bg-playground/BGSTM/sessions/bb8b1cee-6871-442b-b48a-b77d07fa2607

Co-authored-by: bg-playground <259109604+bg-playground@users.noreply.github.com>
Agent-Logs-Url: https://github.com/bg-playground/BGSTM/sessions/bb8b1cee-6871-442b-b48a-b77d07fa2607

Co-authored-by: bg-playground <259109604+bg-playground@users.noreply.github.com>
Copilot AI changed the title [WIP] Implement artifacts upload endpoint for external-results API feat(#298): artifact upload endpoint + pluggable storage backend May 8, 2026
Copilot AI requested a review from bg-playground May 8, 2026 16:43
@bg-playground

Copy link
Copy Markdown
Owner

@copilot

Reviewed end-to-end. The architecture is clean — StorageBackend ABC + get_storage() factory, the #317-round-1 enum lessons are visibly applied (create_type=False from commit 1 in both migration and model), audit-log details matches the five-field contract that PR #314's assert.py will reconstruct from, and the test suite covers all the acceptance-criteria axes.

Two issues to address before merge, plus a small cosmetic.


🛑 1. Path traversal via user-controlled filename

filename flows from the form field straight into the storage key, which LocalFsBackend.save joins onto _root:

# backend/app/api/external_results.py
storage_key = f"{case_result_id}/{_uuid_module.uuid4().hex}/{filename}"
# backend/app/storage/local.py
def save(self, stream, *, key, content_type):
    dest = self._root / key
    dest.parent.mkdir(parents=True, exist_ok=True)
    with dest.open("wb") as fp:
        shutil.copyfileobj(stream, fp)

A filename = "../../../tmp/pwned.png" resolves outside _root. The intervening UUID-hex segment doesn't help — one extra .. undoes it. This is exploitable today on the local backend; once a real S3Backend lands it'd also affect S3 keys (less catastrophic but still bad hygiene).

Suggested fix (defense in depth — both layers):

# backend/app/api/external_results.py
import os, re

_FILENAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,255}$")

safe_name = os.path.basename(filename)
if not _FILENAME_RE.fullmatch(safe_name):
    raise HTTPException(
        status_code=422,
        detail={
            "code": "validation_error",
            "message": "filename must match [A-Za-z0-9._-]{1,255} after basename stripping",
            "details": None,
        },
    )
storage_key = f"{case_result_id}/{_uuid_module.uuid4().hex}/{safe_name}"
# backend/app/storage/local.py — second line of defense
def save(self, stream, *, key, content_type):
    dest = (self._root / key).resolve()
    if not dest.is_relative_to(self._root.resolve()):
        raise ValueError(f"storage key {key!r} escapes artifact root")
    dest.parent.mkdir(parents=True, exist_ok=True)
    ...

Please add a test that filename = "../etc/passwd" (and a couple of other variants — null byte, backslash, absolute path) returns 422 and writes nothing.


⚠️ 2. Size enforcement isn't actually streaming

The PR description says the server "streams in 64 KiB chunks" and the spec doc says "The full body is never buffered into memory." Given the handler signature, that isn't true today:

async def upload_artifact(
    case_result_id: str = Form(...),
    kind: str = Form(...),
    filename: str = Form(...),
    file: UploadFile = File(...),
    ...
):

With Form(...) / File(...), FastAPI/Starlette fully parses the multipart body via python-multipart before the handler is invoked. By the time the chunk loop runs, file is already a SpooledTemporaryFile containing the entire upload (spilled to disk past ~1 MiB). The await file.read(_ARTIFACT_CHUNK_SIZE) loop is reading from that buffered file, not from the network stream.

Practical impact: a 10 GiB upload with BGSTM_ARTIFACT_MAX_BYTES=50 MiB is still fully received off the wire (and spooled to a Starlette-managed tmp file) before being rejected with 413. The DoS vector the limit was meant to close remains open.

The partial-write test passes anyway because the post-buffer chunk loop does write incrementally. The test can't distinguish "truly streaming" from "buffer first, then loop over the buffer" — so it's not catching the regression class it's documented to catch.

Two acceptable resolutions, pick whichever you prefer:

(A) Honest scope reduction (recommended for this PR)

  • Update PR description and spec doc §f to say something like "Server enforces the size limit after the multipart body has been received and spooled by Starlette. True in-stream early-abort is tracked as a follow-up; deploy behind a reverse-proxy client_max_body_size for first-line DoS protection."
  • Add a recommended client_max_body_size line to deploy docs.
  • Reword the test docstring so it doesn't claim to catch buffer-vs-stream regressions.
  • File a follow-up issue: "Artifact upload: enforce size limit during multipart parse, not after" — solution there is a streaming parser (streaming-form-data or python-multipart's MultipartParser) reading request.stream() and aborting mid-body.

(B) Real streaming in this PR

  • Drop Form(...)/File(...) and parse request.stream() manually with a streaming multipart parser.
  • Reject as soon as the running byte total crosses the limit; close the connection.
  • Significantly larger change; keep for a follow-up unless you specifically want it now.

I'd lean (A) — the contract with the reporter doesn't change, the audit-log shape doesn't change, and we keep this PR focused.


3. Cosmetic: model/migration index-name mismatch

  • Model declares index=True on case_result_id → SQLAlchemy default name ix_external_case_artifacts_case_result_id
  • Migration creates explicit idx_external_case_artifacts_case_result_id

Functionally identical, but alembic revision --autogenerate against this state will propose drop+recreate. One-line fix — drop index=True from the column and rely on the explicit migration index, or align names via __table_args__ = (Index("idx_...", "case_result_id"),).


Out of scope for this PR (just confirming)

Once #1 and #2 are addressed I'm a 👍.

Copilot AI and others added 2 commits May 8, 2026 16:57
…w #4408185626)

- Path traversal (issue 1): reject filenames with path separators or chars
  outside [A-Za-z0-9._-]{1,255} with 422; second-line defense in
  LocalFsBackend.save() via resolve() + is_relative_to() check; 8 new
  parametrized path-traversal tests + LocalFsBackend direct-key test
- Streaming scope (issue 2, option A): reword 413 test docstring and spec
  doc §7 to accurately describe Starlette buffering; recommend reverse-proxy
  client_max_body_size for first-line DoS protection
- Index name alignment (issue 3): replace index=True on column with explicit
  __table_args__ Index("idx_external_case_artifacts_case_result_id", ...)
  matching the migration name

Agent-Logs-Url: https://github.com/bg-playground/BGSTM/sessions/abee1c5c-cf1f-45a2-86d5-8a8c18f06506

Co-authored-by: bg-playground <259109604+bg-playground@users.noreply.github.com>
- _SAFE_FILENAME_RE now requires first char to be alphanumeric, blocking
  ".", "..", "..." and similar dot-only names from passing the allowlist
- Add ".", "..", "..." parametrized test cases to TestFilenameValidation
- Add comment to __table_args__ Index explaining the explicit name matches
  the migration to prevent alembic autogenerate noise

Agent-Logs-Url: https://github.com/bg-playground/BGSTM/sessions/abee1c5c-cf1f-45a2-86d5-8a8c18f06506

Co-authored-by: bg-playground <259109604+bg-playground@users.noreply.github.com>

Copilot AI commented May 8, 2026

Copy link
Copy Markdown
Contributor Author

@copilot

Reviewed end-to-end. The architecture is clean — StorageBackend ABC + get_storage() factory, the #317-round-1 enum lessons are visibly applied (create_type=False from commit 1 in ...

All three issues addressed in commits a2adc37 and 3a8991f:

Issue 1 — Path traversal:

  • _SAFE_FILENAME_RE now requires the first character to be alphanumeric (^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$), which blocks ., .., ..., and dot-only names.
  • Added safe_filename != filename guard — any filename containing a path separator (so it would be stripped by os.path.basename) is rejected with 422 rather than silently reused.
  • LocalFsBackend.save() uses .resolve() + is_relative_to() as the second line of defense.
  • 12 parametrized test cases: ../etc/passwd, /etc/shadow, subdir/file.png, null byte, backslash, 256-char name, ., .., ..., plus a direct LocalFsBackend key-escape test.

Issue 2 — Streaming scope: Went with option A. The 413 test docstring and spec doc §7 now accurately describe the Starlette post-buffer behavior and recommend a reverse-proxy client_max_body_size for first-line DoS protection.

Issue 3 — Index name: index=True replaced with explicit __table_args__ = (Index("idx_external_case_artifacts_case_result_id", "case_result_id"),) with a comment explaining it matches the migration name to prevent autogenerate noise.

@bg-playground

Copy link
Copy Markdown
Owner

@copilot

LGTM ✅ — all three review items resolved cleanly.

Review item Resolution
Path traversal on filename os.path.basename + _SAFE_FILENAME_RE (^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$) at the API layer + is_relative_to(self._root.resolve()) defense-in-depth in LocalFsBackend.save. New parametrized tests cover ., .., ..., null bytes, backslashes, length overflow.
"Streaming" size enforcement claim PR body and spec §c.7 are now honest: post-buffer enforcement, with a clear note that FastAPI/Starlette spools the body first and a recommendation to configure reverse-proxy client_max_body_size for first-line DoS protection. True in-stream early-abort tracked in #320.
Migration / model index name mismatch Model now declares __table_args__ = (Index("idx_external_case_artifacts_case_result_id", "case_result_id"),) matching the migration name — no autogenerate noise.

The #317-round-1 lessons are visibly applied: create_type=False on both Alembic ENUM and model-side Enum(...) from commit 1, manual .create(checkfirst=True) / .drop(checkfirst=True), explicit name="artifact_kind". Audit-log details carries all five fields the smoke assert.py in #314 will reconstruct from. get_storage() is a function, not a singleton — tests swap settings cleanly.

Two optional, non-blocking nits for whenever (don't hold up merge):

  1. Spec §c.7 line 357 still says "size_bytes is counted while streaming the body" — mildly inconsistent with the "Note on buffering" 15 lines below. One-word swap to "while reading" if you're already in the file.
  2. [v0.3] Artifact upload: enforce size limit during multipart parse (streaming, DoS-safe) #320 cross-reference isn't in the PR body or spec yet. A (tracked in #320) near the size-enforcement note would close the loop.

Separately, I noticed §d still documents "Artifacts — deduplication by SHA-256" but the implementation here doesn't hash or dedup. Pre-existing spec drift, not this PR's job — happy to file a follow-up issue to either implement or remove from the spec.

Approving. 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Endpoints] Artifact upload with pluggable storage backend

2 participants